MS Graph API with Python Flask
A hands-on guide to connecting your web app to Microsoft 365 — reading emails, calendars, and more using the Microsoft Graph API.
What is Microsoft Graph?
Microsoft Graph is a single REST API that lets your app access data across Microsoft 365 — Outlook emails, calendar events, OneDrive files, Teams messages, and more. Instead of talking to each service separately, everything goes through one endpoint: https://graph.microsoft.com
How the Login Flow Works
Before we write any code, understand what happens when a user clicks "Sign in with Microsoft":
This is called the Authorization Code Flow. Your app never sees the user's password — it only gets a token that grants access to specific data (like emails) that the user consented to.
What We'll Build
By the end of this guide you'll have a working Flask app that:
- Lets users sign in with their Microsoft account
- Reads their recent emails from Outlook
- Reads their upcoming calendar events
- Displays everything in a clean web interface
Prerequisites
| Requirement | Details |
|---|---|
| Python | 3.10+ — you have 3.11.9 via pyenv |
| Microsoft Account | Any M365 / Outlook.com account |
| Azure App Registration | ✅ You've already created this |
| Code Editor | VS Code or any editor |
Configure Authentication in Azure
Set up the redirect URI so Microsoft knows where to send users back after they sign in.
Add a Redirect URI
http://localhost:5000/getAToken
/getAToken?Verify API Permissions
Go to API Permissions in the left sidebar and make sure these are listed:
| Permission | Type | What It Does |
|---|---|---|
User.Read | Delegated | Read user's basic profile |
Mail.Read | Delegated | Read user's email |
Calendars.Read | Delegated | Read user's calendar events |
If Mail.Read and Calendars.Read aren't there yet, click "Add a permission" → Microsoft Graph → Delegated permissions, search for each, tick them, and click "Add permissions".
Note Your IDs
Go to Overview and copy these two values — you'll need them in Step 3:
| Field | Where to Find It |
|---|---|
| Application (client) ID | Overview page, top section |
| Directory (tenant) ID | Overview page, top section |
Create a Client Secret
Your app needs a secret password to prove its identity when exchanging the auth code for a token.
Flask Dev Secret (or anything descriptive)You now have three pieces of info. Keep them handy:
| What | Looks Like |
|---|---|
| Client ID | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
| Tenant ID | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
| Client Secret | aBcDeFg~xYz123456789... |
Set Up the Python Project
Create the project folder, virtual environment, install dependencies, and configure your secrets.
Project Structure
├── app.py # Main Flask application
├── app_config.py # Configuration (reads .env)
├── .env # Your secrets (never commit!)
├── requirements.txt # Python dependencies
└── templates/
├── index.html # Home / login page
├── display.html # Show Graph API results
└── auth_error.html # Error page
Terminal Commands
# Create project folder mkdir ms-graph-flask cd ms-graph-flask # Create virtual environment python -m venv venv # Activate it # On Mac/Linux: source venv/bin/activate # On Windows: venv\Scripts\activate # Install dependencies pip install flask identity requests python-dotenv
identity — Microsoft's official MSAL wrapper for Flask (makes auth super easy)
requests — HTTP client for calling Graph API
python-dotenv — loads your .env secrets
Create the .env File
# Replace with YOUR values from Azure Portal CLIENT_ID=paste-your-client-id-here CLIENT_SECRET=paste-your-client-secret-value-here AUTHORITY=https://login.microsoftonline.com/paste-your-tenant-id-here
.env to your .gitignore file. These secrets must stay local.
Create app_config.py
import os from dotenv import load_dotenv load_dotenv() # reads .env file CLIENT_ID = os.getenv("CLIENT_ID") CLIENT_SECRET = os.getenv("CLIENT_SECRET") AUTHORITY = os.getenv("AUTHORITY") # Where Microsoft redirects after login REDIRECT_PATH = "/getAToken" # What data your app wants to access SCOPE = ["User.Read", "Mail.Read", "Calendars.Read"] # Graph API base URL ENDPOINT = "https://graph.microsoft.com/v1.0"
Write the Flask Application
This is the core of your app — handling login, callback, and Graph API calls.
import identity.web import requests from flask import Flask, redirect, render_template, request, session, url_for import app_config # ─── Create Flask app ─── app = Flask(__name__) app.config["SECRET_KEY"] = "super-secret-dev-key-change-in-production" # ─── Set up Microsoft authentication ─── auth = identity.web.Auth( session=session, authority=app_config.AUTHORITY, client_id=app_config.CLIENT_ID, client_credential=app_config.CLIENT_SECRET, ) # ─── HOME PAGE ─── @app.route("/") def index(): if not (app.config["SECRET_KEY"]): return render_template("config_error.html") return render_template( "index.html", user=auth.get_user(), # This generates the Microsoft login URL auth_url=auth.log_in( scopes=app_config.SCOPE, redirect_uri=url_for("auth_response", _external=True), ), ) # ─── CALLBACK: Microsoft redirects here after login ─── @app.route(app_config.REDIRECT_PATH) def auth_response(): result = auth.complete_log_in(request.args) if "error" in result: return render_template("auth_error.html", result=result) return redirect(url_for("index")) # ─── LOGOUT ─── @app.route("/logout") def logout(): return redirect(auth.log_out(url_for("index", _external=True))) # ─── CALL GRAPH API (generic helper) ─── def call_graph(endpoint): """Call Microsoft Graph with the user's token.""" token = auth.get_token_for_user(app_config.SCOPE) if "error" in token: return redirect(url_for("index")) response = requests.get( endpoint, headers={"Authorization": "Bearer " + token["access_token"]}, ).json() return response # ─── READ EMAILS ─── @app.route("/emails") def emails(): user = auth.get_user() if not user: return redirect(url_for("index")) # Get top 10 emails from inbox data = call_graph( f"{app_config.ENDPOINT}/me/messages?$top=10&$select=subject,from,receivedDateTime,bodyPreview" ) return render_template("display.html", user=user, data=data, title="Emails") # ─── READ CALENDAR ─── @app.route("/calendar") def calendar(): user = auth.get_user() if not user: return redirect(url_for("index")) # Get next 10 upcoming events data = call_graph( f"{app_config.ENDPOINT}/me/events?$top=10&$select=subject,start,end,location,organizer" ) return render_template("display.html", user=user, data=data, title="Calendar") # ─── RUN ─── if __name__ == "__main__": app.run(debug=True, port=5000)
Code Walkthrough
Let's break down what each part does:
identity.web.Auth
This is Microsoft's official helper. It handles the entire OAuth flow for you — generating login URLs, exchanging codes for tokens, refreshing expired tokens, and storing everything in the Flask session.
auth.log_in()
Generates the URL that sends the user to Microsoft's login page. The scopes parameter tells Microsoft what permissions your app needs (emails, calendar, etc.).
auth.complete_log_in()
Called when Microsoft redirects back to your app. It validates the response, exchanges the authorization code for access + refresh tokens, and stores them in the session.
call_graph()
Our helper function that gets a valid token and makes GET requests to the Graph API. The token goes in the Authorization: Bearer header — this is how every Graph API call is authenticated.
Create the HTML Templates
Three simple Jinja2 templates to display the login page, data, and errors.
templates/index.html
<!DOCTYPE html>
<html>
<head>
<title>MS Graph Flask App</title>
<style>
body { font-family: 'Segoe UI', sans-serif; max-width: 800px;
margin: 40px auto; padding: 0 20px; background: #f5f5f5; }
.card { background: white; border-radius: 8px; padding: 32px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1); margin: 20px 0; }
h1 { color: #0078d4; }
.btn { display: inline-block; padding: 12px 24px; background: #0078d4;
color: white; text-decoration: none; border-radius: 6px;
font-weight: 600; margin: 8px 4px; }
.btn:hover { background: #106ebe; }
.btn-danger { background: #d83b01; }
.btn-danger:hover { background: #c23501; }
.welcome { color: #107c10; font-size: 18px; }
</style>
</head>
<body>
<div class="card">
<h1>📊 MS Graph API Explorer</h1>
{% if user %}
<p class="welcome">
✅ Signed in as <strong>{{ user.get("name", "Unknown") }}</strong>
</p>
<p>What would you like to explore?</p>
<a href="/emails" class="btn">📧 Read My Emails</a>
<a href="/calendar" class="btn">📅 View My Calendar</a>
<br><br>
<a href="/logout" class="btn btn-danger">Sign Out</a>
{% else %}
<p>Sign in with your Microsoft account to explore the Graph API.</p>
<a href="{{ auth_url }}" class="btn">🔐 Sign in with Microsoft</a>
{% endif %}
</div>
</body>
</html>templates/display.html
<!DOCTYPE html>
<html>
<head>
<title>{{ title }} — MS Graph</title>
<style>
body { font-family: 'Segoe UI', sans-serif; max-width: 900px;
margin: 40px auto; padding: 0 20px; background: #f5f5f5; }
.card { background: white; border-radius: 8px; padding: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1); margin: 12px 0; }
h1 { color: #0078d4; }
.item { border-left: 3px solid #0078d4; padding: 12px 16px;
margin: 8px 0; background: #fafafa; border-radius: 0 6px 6px 0; }
.item h3 { margin: 0 0 4px; color: #333; }
.item p { margin: 2px 0; color: #666; font-size: 14px; }
.back { display: inline-block; padding: 10px 20px; background: #0078d4;
color: white; text-decoration: none; border-radius: 6px;
margin-top: 16px; }
.raw { background: #1e1e1e; color: #d4d4d4; padding: 16px;
border-radius: 8px; font-family: monospace; font-size: 12px;
white-space: pre-wrap; overflow-x: auto; margin-top: 16px;
max-height: 300px; overflow-y: auto; }
</style>
</head>
<body>
<h1>{{ title }}</h1>
<p>Signed in as <strong>{{ user.get("name", "Unknown") }}</strong></p>
{% if data and data.get("value") %}
{% for item in data["value"] %}
<div class="item">
{% if title == "Emails" %}
<h3>{{ item.get("subject", "(No subject)") }}</h3>
<p>📨 From: {{ item.get("from", {}).get("emailAddress", {}).get("address", "Unknown") }}</p>
<p>🕒 {{ item.get("receivedDateTime", "")[:16] }}</p>
<p>{{ item.get("bodyPreview", "")[:120] }}...</p>
{% elif title == "Calendar" %}
<h3>{{ item.get("subject", "(No subject)") }}</h3>
<p>📅 {{ item.get("start", {}).get("dateTime", "")[:16] }}
→ {{ item.get("end", {}).get("dateTime", "")[:16] }}</p>
<p>📍 {{ item.get("location", {}).get("displayName", "No location") }}</p>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="card">
<p>No {{ title.lower() }} found, or there was an error.</p>
</div>
{% endif %}
<!-- Show raw JSON for learning -->
<details>
<summary style="cursor:pointer; margin-top:20px; color:#0078d4; font-weight:600;">
🔍 View Raw JSON Response (for learning)
</summary>
<div class="raw">{{ data | tojson(indent=2) }}</div>
</details>
<a href="/" class="back">← Back Home</a>
</body>
</html>templates/auth_error.html
<!DOCTYPE html>
<html>
<head><title>Auth Error</title></head>
<body style="font-family: 'Segoe UI', sans-serif; max-width: 600px; margin: 60px auto; text-align: center;">
<h1 style="color: #d83b01;">⚠️ Authentication Error</h1>
<p><strong>Error:</strong> {{ result.get("error") }}</p>
<p><strong>Description:</strong> {{ result.get("error_description") }}</p>
<a href="/" style="display:inline-block; margin-top:20px; padding:10px 20px; background:#0078d4; color:white; text-decoration:none; border-radius:6px;">
Try Again
</a>
</body>
</html>Run Your App & Sign In
Moment of truth — let's fire up the Flask server and sign in with Microsoft.
Start the Server
# Make sure you're in the project folder with venv active cd ms-graph-flask source venv/bin/activate # or venv\Scripts\activate on Windows # Run! python app.py
You should see:
* Running on http://127.0.0.1:5000 * Debug mode: on
Test the Flow
Go back to Azure → Authentication and check the redirect URI is exactly
http://localhost:5000/getAToken (no trailing slash, lowercase, http not https).AADSTS65001 consent error?
Go to Azure → API Permissions → click "Grant admin consent for [your org]" if you see this button.
Module not found?
Make sure your virtual environment is activated and you ran
pip install flask identity requests python-dotenv.
Read Your Emails
Click "Read My Emails" in your app — let's understand what's happening under the hood.
The Graph API Call
When you click the emails button, your app makes this HTTP request:
GET https://graph.microsoft.com/v1.0/me/messages?$top=10&$select=subject,from,receivedDateTime,bodyPreview Headers: Authorization: Bearer eyJ0eXAi... (your access token)
Breaking Down the URL
| Part | Meaning |
|---|---|
/me | The signed-in user |
/messages | Their email messages |
$top=10 | Return only the first 10 results |
$select=... | Only return these fields (faster response) |
$filter — filter results (e.g., unread only)$orderby — sort results$search — full-text search$count — include total countExample:
/me/messages?$filter=isRead eq false&$top=5 returns only unread emails.
Explore the Raw JSON
Click "View Raw JSON Response" in the display page. This is the actual Graph API response. Notice the structure:
{
"@odata.context": "...",
"value": [
{
"subject": "Meeting Tomorrow",
"from": {
"emailAddress": {
"name": "John Doe",
"address": "[email protected]"
}
},
"receivedDateTime": "2026-03-25T10:30:00Z",
"bodyPreview": "Hi, just confirming..."
},
// ... more emails
]
}The value array contains all the emails. Each email is a JSON object with the fields you asked for in $select.
Read Your Calendar
Same pattern, different endpoint — that's the beauty of Graph API.
The Calendar API Call
GET https://graph.microsoft.com/v1.0/me/events?$top=10&$select=subject,start,end,location,organizer
| Part | Meaning |
|---|---|
/me/events | The signed-in user's calendar events |
start, end | Event time range (includes timezone) |
location | Where the meeting is |
organizer | Who created the event |
/me/events returns all events. For a date-range view (like "this week"), use:/me/calendarView?startDateTime=2026-03-25T00:00:00&endDateTime=2026-03-31T23:59:59This is more like how a calendar app works.
The Key Pattern
Notice something? Every Graph call follows the exact same pattern:
That's it. The call_graph() function in your app handles this pattern. To access any new resource, you just change the endpoint URL. The auth stays the same.
What's Next?
You've built a working MS Graph app! Here are your next learning paths.
Try These Graph Endpoints
Add new routes to your Flask app using the same call_graph() pattern:
| What | Endpoint | Permission Needed |
|---|---|---|
| Your profile photo | /me/photo/$value | User.Read |
| OneDrive files | /me/drive/root/children | Files.Read |
| Send an email | POST /me/sendMail | Mail.Send |
| Teams chats | /me/chats | Chat.Read |
| Contacts | /me/contacts | Contacts.Read |
Remember to add the required permissions in Azure → API Permissions before using new endpoints.
Deeper Learning
- Graph Explorer — developer.microsoft.com/graph/graph-explorer — test any endpoint in the browser before writing code
- Batch requests — call multiple endpoints in one HTTP request
- Webhooks / Change Notifications — get notified when data changes (new email arrives)
- Delta queries — only fetch data that changed since your last request
- Application permissions — build background services that run without a user
Integrate with Your AI Stack
Now that you understand Graph API, consider connecting it to your agentic AI work:
- MCP Server — Build a Graph API MCP server so your LangGraph agents can read emails and calendars
- RAG Pipeline — Index Outlook emails or OneDrive documents into ChromaDB for retrieval
- Multi-Agent System — Add a "Microsoft 365 Agent" to your LangGraph orchestrator alongside your Gmail agent
